2026_st_germain_deep_in_it.py

#

SPDX-FileCopyrightText: 2026 Marco Lopes De Bessa & Visay Sourivong SPDX-FileCopyrightText: 2026 AlICe laboratory https://alicelab.be

SPDX-License-Identifier: GPL-3.0-or-later

#

Developed on Blender 5.0.1 Saint-Germain “Deep in It”

import math
import random

import bpy
#
###################################################### CLEAN THAT UP

#

Collections contenant les formes sources dessinées à la main et les configurations obtenues Elles ne doivent jamais être supprimées lors du nettoyage

protected_collections = [
    "FORMES PIANO - SOURCE",
    "FORMES SYNTH - SOURCE",
    "_CONFIG_01",
    "_CONFIG_02",
    "_CONFIG_03",
    "_CONFIG_04",
]
#

Sélection de tous les objets présents dans la scène

bpy.ops.object.select_all(action="SELECT")

for obj in bpy.context.selected_objects:
    if any(col.name in protected_collections for col in obj.users_collection):
        obj.select_set(False)
#

Suppression de tous les objets générés précédemment

bpy.ops.object.delete()
bpy.ops.outliner.orphans_purge()
#
###################################################### COLLECTIONS

#

Fonction utilitaire : récupère une collection existante ou la crée si elle n’existe pas encore

def get_or_create_collection(name):
    collection = bpy.data.collections.get(name)

    if collection is None:
        collection = bpy.data.collections.new(name)
        bpy.context.scene.collection.children.link(collection)

    return collection
#

Collections utilisées pour organiser les différents éléments du projet

col_synth = get_or_create_collection("BLOB SYNTH")
col_piano = get_or_create_collection("BLOB PIANO")
col_cube = get_or_create_collection("REFERENCE CUBE")
col_2D = get_or_create_collection("PICKED SHAPES")
#
###################################################### REFERENCE CUBE

#

Création du cube de référence servant de volume de travail

bpy.ops.mesh.primitive_cube_add(size=10, location=(0, 0, 5))

reference_cube = bpy.context.object
reference_cube.name = "reference_cube"
reference_cube.display_type = (
    "WIRE"  # Affichage filaire pour visualiser les limites sans masquer les formes
)
reference_cube.hide_render = True
#

Retire le cube de sa collection par défaut

for collection in list(reference_cube.users_collection):
    collection.objects.unlink(reference_cube)
#

Place le cube dans la collection REFERENCE CUBE

col_cube.objects.link(reference_cube)
#
###################################################### CUBE PARAMETERS

#

Dimensions du cube de référence

cube_size = 10.0
cube_min_z = 0.0
cube_max_z = 10.0
#

Décalage vertical utilisé pour placer les projections 2D sous le cube

under_cube_offset = 2.0
under_cube_z = cube_min_z - under_cube_offset


piano_z_offset = 0
#
###################################################### NUMBER OF SHAPES

#

Nombre minimum et maximum de formes utilisées pour construire le blob du synth

n_synth_min = 8
n_synth_max = 12
#

Nombre minimum et maximum de formes utilisées pour construire le blob du piano

n_piano_min = 8
n_piano_max = 12
#

Sélection aléatoire du nombre de formes pour cette génération

n_synth = random.randint(n_synth_min, n_synth_max)
n_piano = random.randint(n_piano_min, n_piano_max)
#
###################################################### MODIFIER PARAMETERS

#

Paramètres du Solidify : contrôlent l’épaisseur du blob

solidify_synth_value = random.uniform(1.2, 1.7)
solidify_piano_value = random.uniform(1.2, 1.7)
#

Paramètres du Remesh : contrôlent le niveau de fusion du blob

voxel_synth = random.uniform(0.2, 0.4)
voxel_piano = random.uniform(0.2, 0.4)
#
###################################################### SOURCE SHAPES

#

Récupération des collections contenant les formes sources

source_synth_col = bpy.data.collections.get("FORMES SYNTH - SOURCE")
source_piano_col = bpy.data.collections.get("FORMES PIANO - SOURCE")
#

Transformation des collections en listes d’objets

all_synth_shapes = list(source_synth_col.objects)
all_piano_shapes = list(source_piano_col.objects)
#

Sélection aléatoire des formes qui seront utilisées pour cette génération

picked_synth = random.sample(all_synth_shapes, n_synth)
picked_piano = random.sample(all_piano_shapes, n_piano)
#
###################################################### UTILS

#

Fonction de sécurité : empêche une valeur de dépasser les limites autorisées

def clamp(value, min_value, max_value):
    return max(min_value, min(max_value, value))
#
###################################################### STACK SHAPES

#

Fonction principale du projet : copie les formes sélectionnées, les positionne dans le cube puis construit progressivement les blobs

def stack_shapes(
    picked_shapes, start_corner, end_corner, target_collection, z_start, label
):

    number_of_shapes = len(
        picked_shapes
    )  # nombre total de formes à placer dans le blob
    slice_height = (
        cube_max_z - z_start
    ) / number_of_shapes  # découpe virtuelle du cube en plusieurs strates verticales
    current_z = z_start  # hauteur de départ pour empiler les formes
#

Limites du cube sur les axes X et Y

    x_min = -cube_size / 2
    x_max = cube_size / 2
    y_min = -cube_size / 2
    y_max = cube_size / 2
#

Parcours de chaque forme sélectionnée

    for index, source_obj in enumerate(picked_shapes, start=1):
#

Copie de la forme source

        new_obj = source_obj.copy()
        new_obj.data = source_obj.data.copy()
#

Nommage de la copie selon le type de blob

        new_obj.name = label + "_shape_" + str(index)
        new_obj.data.name = new_obj.name + "_mesh"
#

Ajout de la copie dans la collection cible

        target_collection.objects.link(new_obj)
#

Rendre l’objet actif

        bpy.ops.object.select_all(action="DESELECT")
        new_obj.select_set(True)
        bpy.context.view_layer.objects.active = new_obj
#

Appliquer les transformations de départ et recentrer l’origine sur la géométrie

        bpy.ops.object.transform_apply(location=True, rotation=True, scale=True)
        bpy.ops.object.origin_set(type="ORIGIN_GEOMETRY", center="BOUNDS")
#

Rotation aléatoire en Z par angles de 90 degrés

        possible_rotations = [0, math.pi / 2, math.pi, 3 * math.pi / 2]
        new_obj.rotation_euler = (0, 0, random.choice(possible_rotations))
        bpy.ops.object.transform_apply(location=False, rotation=True, scale=False)
#

Dimensions après rotation

        dim_x = new_obj.dimensions.x if new_obj.dimensions.x > 0 else 1
        dim_y = new_obj.dimensions.y if new_obj.dimensions.y > 0 else 1
#

Scale uniforme pour faire rentrer la forme dans les limites du cube

        uniform_scale = min(cube_size / dim_x, cube_size / dim_y)
        new_obj.scale = (uniform_scale, uniform_scale, 1.0)
        bpy.ops.object.transform_apply(location=False, rotation=False, scale=True)
#

Dimensions finales utilisées ensuite pour vérifier que la forme ne sort pas du cube

        dim_x_final = new_obj.dimensions.x
        dim_y_final = new_obj.dimensions.y
#

---------------------------------------------------- POSITION X/Y

#

Valeur comprise entre 0 et 1 utilisée pour répartir progressivement les formes le long de la trajectoire du blob

        if number_of_shapes == 1:
            progression = 0.0
        else:
            progression = (index - 1) / (number_of_shapes - 1)
#

Trajectoire suivie par les formes entre les deux coins du cube

        location_x = start_corner[0] + progression * (end_corner[0] - start_corner[0])
        location_y = start_corner[1] + progression * (end_corner[1] - start_corner[1])
#

Léger décalage aléatoire pour éviter une disposition trop régulière

        location_x += random.uniform(-0.6, 0.6)
        location_y += random.uniform(-0.6, 0.6)
#

Limites pour empêcher la forme de sortir du cube

        free_x_min = x_min + dim_x_final / 2
        free_x_max = x_max - dim_x_final / 2
        free_y_min = y_min + dim_y_final / 2
        free_y_max = y_max - dim_y_final / 2
#

Vérification finale : la forme reste entièrement contenue dans le cube

        location_x = clamp(location_x, free_x_min, free_x_max)
        location_y = clamp(location_y, free_y_min, free_y_max)
#

---------------------------------------------------- TOUCH CUBE FACE Contrainte géométrique : chaque forme doit toucher au moins une face du cube Cela renforce l’idée que le cube “sculpte” le blob

#

Choix aléatoire de la face à toucher

        if random.random() < 0.5:
#

touche une face X

            if location_x < 0:
                location_x = free_x_min
            else:
                location_x = free_x_max
#

touche une face Y

        elif location_y < 0:
            location_y = free_y_min
        else:
            location_y = free_y_max
#

---------------------------------------------------- POSITION Z

#

Placement vertical aléatoire dans la strate attribuée à la forme

        location_z = current_z + random.uniform(slice_height * 0.1, slice_height * 0.9)
        current_z += slice_height

        new_obj.location = (location_x, location_y, location_z)
#

Création d’une projection 2D de la forme sous le cube Permet de visualiser les formes sources utilisées pour générer le blob

        new_obj_picked = new_obj.copy()
        new_obj_picked.data = new_obj.data.copy()

        new_obj_picked.name = label + "_shape_2D_" + str(index)
        new_obj_picked.data.name = new_obj_picked.name + "_mesh"

        new_obj_picked.location = (location_x, location_y, under_cube_z)

        col_2D.objects.link(new_obj_picked)
        new_obj_picked.display_type = "WIRE"
#
###################################################### STACK BOTH BLOBS

#

Génération du blob synth : progression du coin inférieur gauche vers le coin supérieur droit

stack_shapes(
    picked_shapes=picked_synth,
    start_corner=(-cube_size / 2, -cube_size / 2),
    end_corner=(cube_size / 2, cube_size / 2),
    target_collection=col_synth,
    z_start=cube_min_z,
    label="synth",
)
#

Génération du blob piano : progression inverse pour créer une trajectoire complémentaire au synth

stack_shapes(
    picked_shapes=picked_piano,
    start_corner=(cube_size / 2, cube_size / 2),
    end_corner=(-cube_size / 2, -cube_size / 2),
    target_collection=col_piano,
    z_start=cube_min_z + piano_z_offset,
    label="piano",
)
#
###################################################### JOIN COLLECTION INTO ONE BLOB

#

Fusionne toutes les formes d’une collection en un seul objet

def join_collection_into_blob(collection, blob_name):
#

Récupération de tous les objets présents dans la collection

    objects_to_join = list(collection.objects)
    bpy.ops.object.select_all(action="DESELECT")
#

Sélection de tous les objets à fusionner

    for obj in objects_to_join:
        obj.select_set(True)

    bpy.context.view_layer.objects.active = objects_to_join[0]
    bpy.ops.object.join()  # Assemblage des formes en un blob unique

    blob = bpy.context.object
    blob.name = blob_name
    blob.data.name = blob_name + "_mesh"
#

Renvoie l’objet fusionné afin qu’il puisse être réutilisé dans les étapes suivantes

    return blob
#
###################################################### SYNTH BLOB MODIFIERS

synth_blob = join_collection_into_blob(col_synth, "synth_blob")
#

Ajout d’épaisseur au blob

solidify_synth = synth_blob.modifiers.new(name="solidify_synth", type="SOLIDIFY")
solidify_synth.thickness = solidify_synth_value
solidify_synth.offset = 0.0
solidify_synth.use_even_offset = True
#

Fusion des formes en une géométrie continue

remesh_synth = synth_blob.modifiers.new(name="remesh_synth", type="REMESH")
remesh_synth.mode = "VOXEL"
remesh_synth.voxel_size = voxel_synth
remesh_synth.use_smooth_shade = True
#

Lissage du blob final

subd_synth = synth_blob.modifiers.new(name="subd_synth", type="SUBSURF")
subd_synth.levels = 3
subd_synth.render_levels = 3
#
###################################################### PIANO BLOB MODIFIERS

piano_blob = join_collection_into_blob(col_piano, "piano_blob")
#

Ajout d’épaisseur au blob

solidify_piano = piano_blob.modifiers.new(name="solidify_piano", type="SOLIDIFY")
solidify_piano.thickness = solidify_piano_value
solidify_piano.offset = 0.0
solidify_piano.use_even_offset = True
#

Fusion des formes en une géométrie continue

remesh_piano = piano_blob.modifiers.new(name="remesh_piano", type="REMESH")
remesh_piano.mode = "VOXEL"
remesh_piano.voxel_size = voxel_piano
remesh_piano.use_smooth_shade = True
#

Lissage du blob final

subd_piano = piano_blob.modifiers.new(name="subd_piano", type="SUBSURF")
subd_piano.levels = 2
subd_piano.render_levels = 2
#
###################################################### MATERIALS

#

Création du matériau métallique blanc du synth

mat_synth = bpy.data.materials.new(name="mat_synth")
mat_synth.use_nodes = True
mat_synth.node_tree.nodes["Principled BSDF"].inputs["Base Color"].default_value = (
    1,
    1,
    1,
    1.0,
)
mat_synth.node_tree.nodes["Principled BSDF"].inputs["Metallic"].default_value = 1
#

Attribution du matériau au blob du synth

synth_blob.data.materials.append(mat_synth)
#

Création du matériau métallique mauve du piano

mat_piano = bpy.data.materials.new(name="mat_piano_mauve_fonce")
mat_piano.use_nodes = True
piano_bsdf = mat_piano.node_tree.nodes["Principled BSDF"]
piano_bsdf.inputs["Base Color"].default_value = (0.136, 0.044, 0.120, 0.502)
piano_bsdf.inputs["Metallic"].default_value = 1
#

Attribution du matériau au blob du piano

piano_blob.data.materials.append(mat_piano)
#
###################################################### HIDE SOURCE COLLECTIONS

#

Masque les formes sources afin d’éviter qu’elles se superposent aux blobs finaux

source_synth_col.hide_viewport = True
source_piano_col.hide_viewport = True
#
###################################################### DESELECT

bpy.ops.object.select_all(action="DESELECT")
#
###################################################### TUBES GRID

col_tubes = get_or_create_collection("TUBES GRID")
#

Supprime les anciens tubes pour éviter les doublons lors d’une nouvelle génération

for obj in list(col_tubes.objects):
    bpy.data.objects.remove(obj, do_unlink=True)
#
###################################################### TUBES PARAMETERS

#

Nombre de tubes généré aléatoirement Représente la présence des drums

n_tubes_x = random.randint(4, 8)
n_tubes_y = random.randint(4, 8)
#

Marge conservée entre les tubes et les bords du cube

margin = 0.6
#

Espace réellement disponible pour répartir les tubes

usable_size_x = cube_size - 2 * margin
usable_size_y = cube_size - 2 * margin
#

Distance entre deux tubes voisins sur la grille

step_x = usable_size_x / (n_tubes_x - 1) if n_tubes_x > 1 else 0
step_y = usable_size_y / (n_tubes_y - 1) if n_tubes_y > 1 else 0
#

Position de départ de la grille de tubes

start_x = -cube_size / 2 + margin
start_y = -cube_size / 2 + margin
#

Les tubes descendent tous 2 unités sous le cube

tube_bottom_z = under_cube_z
#

Epaisseur des tubes générée aléatoirement Représente l’intensité des drums

tube_radius = random.uniform(0.04, 0.125)
#
###################################################### CREATE TUBES

#

Compteur utilisé pour donner un nom unique à chaque tube

tube_index = 1
#

Parcours de la grille pour créer chaque tube

for ix in range(n_tubes_x):
    for iy in range(n_tubes_y):
#

Calcul de la position du tube sur la grille

        x = start_x + ix * step_x
        y = start_y + iy * step_y
#

---------------------------------------------------- HEIGHT Tous les tubes montent jusqu’au sommet du cube

        tube_top_z = cube_max_z
#

Le tube commence toujours à -2

        tube_height = tube_top_z - tube_bottom_z
        tube_center_z = tube_bottom_z + tube_height / 2
#

---------------------------------------------------- RADIUS Épaisseur commune à tous les tubes de cette génération

        radius = tube_radius
#

---------------------------------------------------- CREATE CYLINDER

        bpy.ops.mesh.primitive_cylinder_add(
            vertices=24,
            radius=radius,
            depth=tube_height,
            location=(x, y, tube_center_z),
        )

        tube = bpy.context.object
        tube.name = "tube_" + str(tube_index)
#

Déplace le tube dans la collection TUBES GRID

        for collection in list(tube.users_collection):
            collection.objects.unlink(tube)

        col_tubes.objects.link(tube)

        tube_index += 1
#
###################################################### DESELECT

bpy.ops.object.select_all(action="DESELECT")